The purpose of Maybrain is to allow easy visualisation of brain connectome and related data and perform various analyses.
The code is built around the class Brain. It contains all the information about the brain and numerous functions to change, measure and highlight those. At its heart is a Networkx object, Brain.G, via which all Networkx functions are available.
Besides this main class, you have four packages with other modules, algorithms, plotting, utils and resources, which will be explained throughout these notebooks.
Several types of data can be input. The basic connectome is made up of two files: a coordinate file and an adjacency matrix. In fact only the second of these is strictly required.
The coordinate file defines the position of each node. It is a text file where each line has four entries: the node index, x, y and z coordinates. e.g.:
0 0.0 3.1 4.4
1 5.3 7.6 8.4
2 3.2 4.4 3.1
The adjacency matrix defines the strength of connection between each pair of nodes. For n nodes it is an n × n text matrix. Nodes in maybrain are labelled 0,1,... and the order of the rows and columns in the adjacency matrix is assumed to correspond to this. Entering an adjacency matrix with the wrong dimensions will lead to certain doom.
In [1]:
from maybrain import brain as mbt
constants
moduleBefore going further in explaining Maybrain's functionalities, it is important to briefly refer the constants
module. This module has some constants which can be used elsewhere, rather than writing the values by hand everywhere being prone to typos.
In further notebooks you will see this module being used in practice, but for now, also just a normal import is required:
In [2]:
from maybrain import constants as ct
# Printing some of the constants
print(ct.WEIGHT)
print(ct.ANAT_LABEL)
resources
packageMaybrain also have another package that can be useful for different things. In its essence, it is just a package with access to files like matrices, properties, etc. When importing this package, you will have access to different variables in the path for the file in your system.
Farther in the documentation you will see this package being used in practice, but for now, also just a normal import is required:
In [3]:
from maybrain import resources as rr
In [4]:
a = mbt.Brain()
print("Nodes: ", a.G.nodes())
print("Edges: ", a.G.edges())
print("Adjacency matrix: ", a.adjMat)
This creates a brain object, where a graph (from the package NetworkX) is stored as a.G
, initially empty.
Then import the adjacency matrix. The import_adj_file() function imports the adjacency matrix to form the nodes of your graph, but does not create any edges (connections), as you can check from the following outputs.
Note the use of the resources
package. In maybrain you can access a dummy adjacency matrix (500x500) for various reasons; in this case, just for testing purposes.
In [5]:
a.import_adj_file(rr.DUMMY_ADJ_FILE_500)
print("Number of nodes:\n", a.G.number_of_nodes())
print("First 5 nodes (notice labelling starting with 0):\n", list(a.G.nodes())[0:5])
print("Edges:\n", a.G.edges())
print("Size of Adjacency matrix:\n", a.adjMat.shape)
If you wish to create a fully connected graph with all the available values in the adjacency matrix, it is necessary to threshold it, which is explained in the next section.
There are a few ways to apply a threshold, either using an absolute threshold across the whole graph to preserve a specified number of edges or percentage of total possible edges; or to apply a local thresholding that begins with the minimum spanning tree and adds successive n-nearest neighbour graphs. The advantage of local thresholding is that the graph will always be fully connected, which is necessary to collect some graph measures.
For an absolute threshold you have several possibilities. Note that our adjacency matrix (a.adjMat
) always stays the same so we can apply all the thresholds we want to create our graph (a.G
) accordingly. Also notice that in this specific case of an undirected graph, are dealing with a symmetric adjacency matrix, so although a.adjMat
will always have the size of 500x500, the a.G
will not.
In [6]:
# Bring everything from the adjacency matrix to a.G
a.apply_threshold()
print("Number of edges (notice it corresponds to the upper half edges of adjacency matrix):\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 1st threshold:\n", a.adjMat.shape)
# Retain the most strongly connected 1000 edges
a.apply_threshold(threshold_type= "totalEdges", value = 1000)
print("\nNumber of edges after 2nd threshold:\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 2nd threshold:\n", a.adjMat.shape)
# Retain the 5% most connected edges as a percentage of the total possible number of edges
a.apply_threshold(threshold_type = "edgePC", value = 5)
print("\nNumber of edges after 3rd threshold:\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 3rd threshold:\n", a.adjMat.shape)
# Retain edges with a weight greater than 0.3
a.apply_threshold(threshold_type= "tVal", value = 0.3)
print("\nNumber of edges after 4th threshold:\n", a.G.number_of_edges())
print("Size of Adjacency matrix after 4th threshold:\n", a.adjMat.shape)
The options for local thresholding are similar. Note that a local thresholding always yield a connected graph, and in the case where no arguments are passed, the graph will be the Minimum Spanning Tree. Local thresholding can be very slow for bigger matrices because in each step it is adding successive N-nearest neighbour degree graphs.
In [7]:
a.local_thresholding()
print("Is the graph connected? ", mbt.nx.is_connected(a.G))
a.local_thresholding(threshold_type="edgePC", value = 5)
print("Is the graph connected? ", mbt.nx.is_connected(a.G))
a.local_thresholding(threshold_type="totalEdges", value = 10000)
print("Is the graph connected? ", mbt.nx.is_connected(a.G))
In a real brain network, an edge with high negative value is as strong as an edge with a high positive value. So, if you want to threshold in order to get the most strongly connected edges (both negative and positive), you just have to pass an argument use_absolute=True
to apply_threshold()
.
In the case of the brain that we are using in this notebook there are not many negative edges. Thus, we have to threshold the 80% most strongly connected edges in order to see a difference (notice the use of the module constants
(ct
) to access the weight property of each edge):
In [8]:
# Thresholding the 80% most strongly connected edges
a.apply_threshold(threshold_type="edgePC", value=80)
for e in a.G.edges(data=True):
# Printing the edges with negative weight
if e[2][ct.WEIGHT] < 0:
print(e) # This line is never executed because a negative weighted edge is not strong enough
# Absolute thresholding of the 70% most strongly connected edges
print("Edges with negative weight which belong to the 70% strongest ones:")
a.apply_threshold(threshold_type="edgePC", value=70, use_absolute=True)
for e in a.G.edges(data=True):
if e[2][ct.WEIGHT] < 0:
print(e)
# Absolute thresholding of the 80% most strongly connected edges
print("\nEdges with negative weight which belong to the 80% strongest ones:")
a.apply_threshold(threshold_type="edgePC", value=80, use_absolute=True)
for e in a.G.edges(data=True):
if e[2][ct.WEIGHT] < 0:
print(e)
In [9]:
a.binarise()
print("Do all the edges have weight of 1?", all(e[2][ct.WEIGHT] == 1 for e in a.G.edges(data=True)))
Also, you can make all the weights to have an absolute value, instead of negative and positive values:
In [10]:
# Applying threshold again because of last changes
a.apply_threshold()
print("Do all the edges have a positive weight before?", all(e[2][ct.WEIGHT] >= 0 for e in a.G.edges(data=True)))
a.make_edges_absolute()
print("Do all the edges have a positive weight?", all(e[2][ct.WEIGHT] >= 0 for e in a.G.edges(data=True)))
You can add spatial info to each node of your graph. You need this information if you want to use the visualisation tools of Maybrain.
To do so, provide Maybrain with a file that has 4 columns: an anatomical label, and x, y and z coordinates. e.g.:
0 70.800000 30.600000 53.320000
1 32.064909 62.154158 69.707911
2 59.870968 92.230014 41.552595
3 19.703504 66.398922 52.878706
Ideally these values would be in MNI space (this makes it easier to import background images for plotting and for some other functions), but this is not absolutely necessary.
We are using the resources
package again to get an already prepated text file with spatial information for a brain with 500 regions in the MNI template:
In [11]:
# Initially, you don't have anatomical/spatial attributes in each node:
print("Attributes: ", mbt.nx.get_node_attributes(a.G, ct.ANAT_LABEL), "/", mbt.nx.get_node_attributes(a.G, ct.XYZ))
#After calling import_spatial_info(), you can see the node's attributes
a.import_spatial_info(rr.MNI_SPACE_COORDINATES_500)
print("Attributes of one node: ",
mbt.nx.get_node_attributes(a.G, ct.ANAT_LABEL)[0],
"/",
mbt.nx.get_node_attributes(a.G, ct.XYZ)[0])
We have seen already that nodes can have properties about spatial information after calling import_spatial_info()
, and edges can have properties about weight after calling applying thresholds.
You can add properties to nodes or edges from a text file. The format should be as follows:
property
node1 value
node2 value2
(...)
node1 node2 value1
node3 node4 value2
(...)
Let's give a specific example. Imagine that you want to add properties about colours. You can use this file, which is transcribed here:
colour
1 red
3 red
6 green
0 blue
1 3 green
1 2 green
1 0 grey
2 3 green
2 0 red
3 0 green
Note that the first line contains the property name. Subsequent lines refer to edges if they contain 3 terms and nodes if they contain 2. The above will give node 1 the property 'colour'
with value 'red'
and node 6 the property 'colour'
with value 'green'
. Nodes 0 and 3 will also have the property 'colour'
but with value 'blue'
and 'red'
, respectively.
The edge connecting nodes 1 and 3 will have the same property with value 'green'
. All the other 5 edges will have the same property but with different values. These properties are stored in the G
object from networkx.
In order to be easier to see the properties features, we will be importing a shorter matrix with just 4 nodes (link here).
From the following code you can see that a warning is printed because we tried to add a property to a node 6
, which doesn't exist. However, the other properties are added.
Note the fact that as the brain is not directed, adding the property to the edge (1,0)
is considered as adding to the edge (0,1)
. The same thing happens with edges (2,0)
and (3,0)
. No property was imported to node 2
because it is not specified in the properties file.
In [12]:
# Creating a new Brain and importing the shorter adjacency matrix
b = mbt.Brain()
b.import_adj_file("data/3d_grid_adj.txt")
b.apply_threshold()
print("Edges and nodes information:")
for e in b.G.edges(data=True):
print(e)
for n in b.G.nodes(data=True):
print(n)
# Importing properties and showing again edges and nodes
print("\nImporting properties...")
b.import_properties("data/3d_grid_properties.txt")
print("\nEdges and nodes information after importing properties:")
for e in b.G.edges(data=True):
print(e)
for n in b.G.nodes(data=True):
print(n)
You can notice that if we threshold our brain again, edges are created from scratch and thus properties are lost. The same doesn't happen with nodes as they are always present in our G
object.
By default, properties of the edges are not imported everytime you threshold the brain. However, you can change that behaviour by setting the field update_props_after_threshold
to True.
In [13]:
# Rethresholding the brain, thus loosing information
b.apply_threshold(threshold_type="totalEdges", value=0)
b.apply_threshold()
print("Edges information:")
for e in b.G.edges(data=True):
print(e)
# Setting field to allow automatic importing of properties after a threshold
print("\nSetting b.update_properties_after_threshold and rethresholding again...")
b.apply_threshold(threshold_type="totalEdges", value=0)
b.update_props_after_threshold = True
b.apply_threshold() # Now, warning is thrown just like before
print("\nEdges information again:")
for e in b.G.edges(data=True):
print(e)
You can also import the properties from a dictionary, both for nodes and edges. In the following example there are two dictionaries being created with the values of a certain property, named own_property
, that will be added to brain:
In [14]:
nodes_props = {0: "val1", 1: "val2"}
edges_props = {(0, 1): "edge_val1", (2,3): "edge_val2"}
b.import_edge_props_from_dict("own_property", edges_props)
b.import_node_props_from_dict("own_property", nodes_props)
print("\nEdges information:")
for e in b.G.edges(data=True):
print(e)
print("\nNodes information:")
for n in b.G.nodes(data=True):
print(n)